# 面经手册 · 第36篇《MyBatis 动态 SQL 怎么实现的?9个标签用法与 OGNL 解析原理》

作者:小傅哥
博客:https://bugstack.cn (opens new window)

沉淀、分享、成长,让自己和他人都能有所收获!😄

# 一、前言

动态 SQL 是 MyBatis 最强大的特性之一,也是面试中出现频率极高的一道考题。

大部分候选人能说出 if、where、foreach 这几个标签的名字,但追问一句:where 标签是怎么自动去除前缀 AND/OR 的?foreach 的 collection 属性到底传什么?trim 和 where/set 是什么关系?动态 SQL 底层解析原理是什么?——大多数人就答不上来了。

本文从 9 个动态 SQL 标签的用法讲起,逐个给出完整代码示例,再深入源码追踪 OGNL 表达式解析和 SqlNode 树的遍历过程。不只是记住标签怎么用,更要知道底层是怎么跑的。

# 二、面试题

谢飞机,小记!,昨天刚背完 MyBatis 八股文,今天又来面试了。

面试官:谢飞机,说说 MyBatis 动态 SQL 是什么?有哪些标签?

谢飞机:动态 SQL 就是根据条件拼接不同的 SQL。有 if、where、foreach、choose、set 这些标签。

面试官:if 标签的 test 属性用的是什么表达式语言?

谢飞机:OGNL... 吧?

面试官:where 标签是怎么自动去掉前缀 AND 的?

谢飞机:嗯... 它内部有个处理逻辑?

面试官:对,那这个逻辑在源码里怎么实现的?和 trim 标签什么关系?

谢飞机:trim... 我只知道它能去前缀后缀...

面试官:foreach 标签的 collection 属性,传 List 和传数组分别写什么?

谢飞机:list?array?

面试官:那传 Map 呢?key 是什么?

谢飞机:... 再见!ヾ( ̄▽ ̄)

# 三、9个动态SQL标签详解

# 1. if — 条件判断

最基础的动态 SQL 标签,根据条件决定是否拼接 SQL 片段。

<select id="findUsers" resultType="User">
    SELECT * FROM user
    WHERE status = 'ACTIVE'
    <if test="name != null">
        AND name LIKE CONCAT('%', #{name}, '%')
    </if>
    <if test="age != null">
        AND age = #{age}
    </if>
</select>
1
2
3
4
5
6
7
8
9
10

test 属性:使用 OGNL 表达式求值,结果为 true 时拼接 SQL 片段。

OGNL 常用判断写法:

<!-- 判断不为空 -->
<if test="name != null and name != ''">

<!-- 调用方法 -->
<if test="list != null and list.size() > 0">

<!-- 判断集合 -->
<if test="ids != null and ids.size() > 0">

<!-- 字符串方法 -->
<if test="name != null and name.indexOf('admin') >= 0">
1
2
3
4
5
6
7
8
9
10
11

# 2. choose/when/otherwise — 多条件分支

类似 Java 的 switch-case,只匹配第一个满足条件的分支。

<select id="findUsers" resultType="User">
    SELECT * FROM user WHERE status = 'ACTIVE'
    <choose>
        <when test="id != null">
            AND id = #{id}
        </when>
        <when test="name != null">
            AND name = #{name}
        </when>
        <otherwise>
            AND create_time > '2024-01-01'
        </otherwise>
    </choose>
</select>
1
2
3
4
5
6
7
8
9
10
11
12
13
14

要点:when 按顺序判断,匹配一个就跳出;otherwise 在所有 when 都不满足时生效。与多个 if 不同,choose 只会命中一个分支。

# 3. where — 智能WHERE子句

自动去除条件前缀的 AND/OR,避免语法错误。

<select id="findUsers" resultType="User">
    SELECT * FROM user
    <where>
        <if test="name != null">
            AND name = #{name}
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
    </where>
</select>
1
2
3
4
5
6
7
8
9
10
11

场景一:name 不为空、age 为空时,生成:

SELECT * FROM user WHERE name = ?
1

AND 被自动去除。

场景二:name 和 age 都不为空时,生成:

SELECT * FROM user WHERE name = ? AND age = ?
1

第一个 AND 被去除,第二个保留。

场景三:name 和 age 都为空时,生成:

SELECT * FROM user
1

WHERE 子句整体不生成。

# 4. set — 智能UPDATE SET子句

自动去除末尾多余的逗号。

<update id="updateUser" parameterType="User">
    UPDATE user
    <set>
        <if test="name != null">
            name = #{name},
        </if>
        <if test="age != null">
            age = #{age},
        </if>
        <if test="email != null">
            email = #{email},
        </if>
    </set>
    WHERE id = #{id}
</update>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

只更新非空字段,set 标签自动去除最后一个条件末尾的逗号。生成示例:

UPDATE user SET name = ?, age = ? WHERE id = ?
1

# 5. trim — 自定义前缀后缀裁剪

where 和 set 的底层实现都依赖 trim,trim 是最灵活的裁剪标签。

<!-- trim 等价于 where 标签 -->
<trim prefix="WHERE" prefixOverrides="AND |OR ">
    <if test="name != null">
        AND name = #{name}
    </if>
    <if test="age != null">
        AND age = #{age}
    </if>
</trim>

<!-- trim 等价于 set 标签 -->
<trim prefix="SET" suffixOverrides=",">
    <if test="name != null">
        name = #{name},
    </if>
    <if test="age != null">
        age = #{age},
    </if>
</trim>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
属性 说明
prefix 内容非空时,添加的前缀
suffix 内容非空时,添加的后缀
prefixOverrides 去除内容开头指定的字符串(多个用
suffixOverrides 去除内容末尾指定的字符串(多个用

# 6. foreach — 集合遍历

最常用的动态 SQL 标签之一,常用于 IN 查询和批量操作。

IN 查询

<select id="findByIds" resultType="User">
    SELECT * FROM user WHERE id IN
    <foreach collection="ids" item="id" open="(" separator="," close=")">
        #{id}
    </foreach>
</select>
1
2
3
4
5
6

批量插入

<insert id="batchInsert">
    INSERT INTO user (name, age) VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.age})
    </foreach>
</insert>
1
2
3
4
5
6

属性详解

属性 说明 示例
collection 遍历的集合对象 List→list,数组→array,Map→Map的key
item 每次迭代的变量名 #{id}
index 索引变量名(List为下标,Map为key) #{idx}
open 开始符号 (
close 结束符号 )
separator 每次迭代之间的分隔符 ,

collection 属性传值规则

// 1. 传 List:collection="list"(默认)或 @Param 指定名称
List<User> findByIds(@Param("ids") List<Long> ids);  // collection="ids"

// 2. 传数组:collection="array"(默认)或 @Param 指定名称
List<User> findByIds(@Param("ids") Long[] ids);  // collection="ids"

// 3. 传 Map:collection 为 Map 的 key
List<User> findByMap(@Param("params") Map<String, List<Long>> params);
// XML 中 collection="params.ids" 或直接传 Map 时 collection 为某个 key
1
2
3
4
5
6
7
8
9

# 7. bind — 变量绑定

在 SQL 解析阶段创建一个变量,供后续 OGNL 表达式引用。

<select id="findUserByName" resultType="User">
    <bind name="pattern" value="'%' + name + '%'" />
    SELECT * FROM user WHERE name LIKE #{pattern}
</select>
1
2
3
4

典型场景:模糊查询,避免使用 ${} 拼接通配符。bind 创建的变量可以用 #{} 引用,安全又灵活。

# 8. sql/include — SQL片段复用

将重复的 SQL 片段抽取出来,多处引用。

<!-- 定义 SQL 片段 -->
<sql id="userColumns">
    id, name, age, email, create_time
</sql>

<!-- 引用 SQL 片段 -->
<select id="findUsers" resultType="User">
    SELECT <include refid="userColumns"/> FROM user
</select>

<select id="findById" resultType="User">
    SELECT <include refid="userColumns"/> FROM user WHERE id = #{id}
</select>
1
2
3
4
5
6
7
8
9
10
11
12
13

注意:sql/include 不是动态 SQL 标签,它们在 MyBatis 解析阶段就完成了替换,不涉及运行时的条件判断。但日常开发中和动态 SQL 配合使用非常频繁。

# 四、OGNL表达式解析原理

# 1. 什么是OGNL

OGNL(Object-Graph Navigation Language)是一种表达式语言,用于读写 Java 对象的属性和调用方法。MyBatis 使用 OGNL 来解析动态 SQL 中 test 属性的表达式。

<!-- OGNL 表达式示例 -->
<if test="name != null">          <!-- 访问属性 -->
<if test="list.size() > 0">       <!-- 调用方法 -->
<if test="type == 'admin'">       <!-- 字符串比较 -->
<if test="age > 18 and age < 60"> <!-- 逻辑运算 -->
1
2
3
4
5

# 2. OGNL在MyBatis中的执行流程

XML 映射文件中的 test 表达式
  ↓
OgnlCache.getValue(expression, context)
  ↓  编译表达式(缓存机制)
Ognl.getValue(parsedExpression, root)
  ↓  根据上下文参数求值
返回 true/false → 决定是否拼接 SQL 片段
1
2
3
4
5
6
7

OgnlCache 源码

// org.apache.ibatis.scripting.xmltags.OgnlCache
public class OgnlCache {

    private static final Map<String, Object> expressionCache = new ConcurrentHashMap<>();

    public static Object getValue(String expression, Object root) {
        try {
            // 编译表达式(有缓存)
            Object parsedExpression = parseExpression(expression);
            // 在 root 对象上求值
            return Ognl.getValue(parsedExpression, root);
        } catch (OgnlException e) {
            throw new RuntimeException("Error evaluating expression '" +
                expression + "'. Cause: " + e, e);
        }
    }

    private static Object parseExpression(String expression) throws OgnlException {
        // 先查缓存,没有再编译
        Object node = expressionCache.get(expression);
        if (node == null) {
            node = Ognl.parseExpression(expression);
            expressionCache.put(expression, node);
        }
        return node;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27

关键点:OGNL 表达式编译结果有缓存,同一条 SQL 多次执行时不会重复编译,性能有保障。

# 3. OGNL上下文中的参数访问

MyBatis 在构建 OGNL 上下文时,会将参数对象放入 _parameter 和方法参数名中:

// DynamicContext 构造时
bindings.put("_parameter", parameter);
bindings.put("_databaseId", databaseId);
// 如果是 @Param 注解的参数,还会放入具体的参数名
1
2
3
4

所以在 test 表达式中可以直接写属性名:

<!-- 单参数 -->
<if test="name != null">  <!-- 等同于 _parameter.name != null -->

<!-- @Param("name") 多参数 -->
<if test="name != null">  <!-- 直接用参数名 -->

<!-- 无 @Param 多参数 -->
<if test="param1 != null"> <!-- 只能用 param1/param2 或 arg0/arg1 -->
1
2
3
4
5
6
7
8

# 五、动态SQL源码解析

# 1. 解析入口:XMLScriptBuilder

MyBatis 解析 XML 映射文件中的 SQL 语句时,通过 XMLScriptBuilder 处理动态 SQL 标签:

// org.apache.ibatis.builder.xml.XMLScriptBuilder
public class XMLScriptBuilder extends BaseBuilder {

    public SqlSource parseScriptNode() {
        // 解析动态标签,构建 SqlNode 树
        MixedSqlNode rootSqlNode = parseDynamicTags(context);
        SqlSource sqlSource;
        if (isDynamic) {
            sqlSource = new DynamicSqlSource(configuration, rootSqlNode);
        } else {
            sqlSource = new RawSqlSource(configuration, rootSqlNode, parameterType);
        }
        return sqlSource;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

# 2. parseDynamicTags:构建SqlNode树

这是核心方法,递归解析 XML 节点,将每个标签转换为对应的 SqlNode 实现:

// org.apache.ibatis.builder.xml.XMLScriptBuilder
protected MixedSqlNode parseDynamicTags(XNode node) {
    List<SqlNode> contents = new ArrayList<>();
    NodeList children = node.getNode().getChildNodes();
    for (int i = 0; i < children.getLength(); i++) {
        XNode child = node.newXNode(children.item(i));
        if (child.getNode().getNodeType() == Node.CDATA_SECTION_NODE
            || child.getNode().getNodeType() == Node.TEXT_NODE) {
            // 文本节点
            String data = child.getStringBody("");
            TextSqlNode textSqlNode = new TextSqlNode(data);
            if (textSqlNode.isDynamic()) {
                // 包含 ${} 的文本 → TextSqlNode
                contents.add(textSqlNode);
                isDynamic = true;
            } else {
                // 纯文本 → StaticTextSqlNode
                contents.add(new StaticTextSqlNode(data));
            }
        } else if (child.getNode().getNodeType() == Node.ELEMENT_NODE) {
            // 标签节点 → 根据标签名创建对应 SqlNode
            String nodeName = child.getNode().getNodeName();
            NodeHandler handler = nodeHandlers.get(nodeName);
            handler.handleNode(child, contents);
            isDynamic = true;
        }
    }
    return new MixedSqlNode(contents);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

# 3. SqlNode体系

每个动态 SQL 标签都对应一个 SqlNode 实现类:

SqlNode(接口)
  ├── MixedSqlNode          — 组合节点,包含多个子 SqlNode
  ├── IfSqlNode             — 对应 <if> 标签
  ├── ChooseSqlNode         — 对应 <choose> 标签
  ├── WhereSqlNode          — 对应 <where> 标签
  ├── SetSqlNode            — 对应 <set> 标签
  ├── TrimSqlNode           — 对应 <trim> 标签
  ├── ForEachSqlNode        — 对应 <foreach> 标签
  ├── VarDeclSqlNode        — 对应 <bind> 标签
  ├── TextSqlNode           — 包含 ${} 的文本
  └── StaticTextSqlNode     — 纯静态文本
1
2
3
4
5
6
7
8
9
10
11

# 4. SqlNode.apply():遍历生成SQL

SqlNode 接口只有一个方法:

public interface SqlNode {
    boolean apply(DynamicContext context);
}
1
2
3

每个 SqlNode 的 apply 方法负责将自己的 SQL 片段追加到 DynamicContext 中。运行时,MixedSqlNode 递归调用所有子节点的 apply 方法:

// MixedSqlNode
public class MixedSqlNode implements SqlNode {
    private final List<SqlNode> contents;

    @Override
    public boolean apply(DynamicContext context) {
        for (SqlNode sqlNode : contents) {
            sqlNode.apply(context);
        }
        return true;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12

IfSqlNode

public class IfSqlNode implements SqlNode {
    private final ExpressionEvaluator evaluator;
    private final String test;
    private final SqlNode contents;

    @Override
    public boolean apply(DynamicContext context) {
        // 使用 OGNL 求值 test 表达式
        if (evaluator.evaluateBoolean(test, context.getBindings())) {
            contents.apply(context);  // 条件为 true,拼接子节点
            return true;
        }
        return false;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

WhereSqlNode

public class WhereSqlNode extends TrimSqlNode {

    private static List<String> prefixList = Arrays.asList("AND ", "OR ", "AND\n", "OR\n",
        "AND\r", "OR\r", "AND\t", "OR\t");

    public WhereSqlNode(Configuration configuration, SqlNode contents) {
        // where 标签就是 trim 的特例!
        // prefix = "WHERE",prefixOverrides = "AND |OR "
        super(configuration, contents, "WHERE", prefixList, null, null);
    }
}
1
2
3
4
5
6
7
8
9
10
11

SetSqlNode

public class SetSqlNode extends TrimSqlNode {

    public SetSqlNode(Configuration configuration, SqlNode contents) {
        // set 标签也是 trim 的特例!
        // prefix = "SET",suffixOverrides = ","
        super(configuration, contents, "SET", null, null, Arrays.asList(","));
    }
}
1
2
3
4
5
6
7
8

关键发现:where 和 set 都是 TrimSqlNode 的子类!where 就是 prefix="WHERE" prefixOverrides="AND |OR " 的 trim,set 就是 prefix="SET" suffixOverrides="," 的 trim。

TrimSqlNode

public class TrimSqlNode implements SqlNode {
    private final SqlNode contents;
    private final String prefix;
    private final String suffix;
    private final List<String> prefixesToOverride;
    private final List<String> suffixesToOverride;

    @Override
    public boolean apply(DynamicContext context) {
       FilteredDynamicContext filteredDynamicContext =
            new FilteredDynamicContext(context, prefix, suffix,
                prefixesToOverride, suffixesToOverride);
        // 先让子节点拼接 SQL
        contents.apply(filteredDynamicContext);
        // 再处理前缀后缀裁剪
        filteredDynamicContext.applyAll();
        return true;
    }

    private class FilteredDynamicContext extends DynamicContext {
        @Override
        public void applyAll() {
            // 1. 子节点拼接完成后,获取完整 SQL
            String sql = delegate.getSqlBuilder().toString().trim();
            // 2. 去除前缀(AND/OR)
            if (prefixesToOverride != null) {
                for (String toRemove : prefixesToOverride) {
                    if (sql.toUpperCase().startsWith(toRemove.toUpperCase())) {
                        sql = sql.substring(toRemove.length());
                        break;
                    }
                }
            }
            // 3. 去除后缀(逗号)
            if (suffixesToOverride != null) {
                for (String toRemove : suffixesToOverride) {
                    if (sql.toUpperCase().endsWith(toRemove.toUpperCase())) {
                        sql = sql.substring(0, sql.length() - toRemove.length());
                        break;
                    }
                }
            }
            // 4. 内容非空时添加 prefix/suffix
            if (sql.length() > 0) {
                if (prefix != null) sql = prefix + sql;
                if (suffix != null) sql = sql + suffix;
            }
            delegate.appendSql(sql);
        }
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

ForEachSqlNode

public class ForEachSqlNode implements SqlNode {
    private final ExpressionEvaluator evaluator;
    private final String collectionExpression;
    private final String item;
    private final String index;
    private final String open;
    private final String close;
    private final String separator;
    private final SqlNode contents;

    @Override
    public boolean apply(DynamicContext context) {
        // 1. 通过 OGNL 获取集合对象
        Iterable<?> iterable = evaluator.evaluateIterable(
            collectionExpression, context.getBindings());
        int i = 0;
        for (Object o : iterable) {
            // 2. 保存旧变量(item/index 可能与外部变量同名)
            int uniqueNumber = context.getUniqueNumber();
            // 3. 将当前迭代项放入上下文
            if (o instanceof Map.Entry) {
                Map.Entry<Object, Object> mapEntry = (Map.Entry<Object, Object>) o;
                context.bind(item, mapEntry.getValue());
                context.bind(index, mapEntry.getKey());
            } else {
                context.bind(index, i);
                context.bind(item, o);
            }
            // 4. 添加 open/separator/close
            if (i == 0 && open != null) {
                context.appendSql(open);
            } else if (separator != null) {
                context.appendSql(separator);
            }
            // 5. 拼接每次迭代的 SQL
            contents.apply(context);
            i++;
        }
        if (i > 0 && close != null) {
            context.appendSql(close);
        }
        return true;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44

# 5. DynamicSqlSource:动态SQL的执行入口

// org.apache.ibatis.scripting.xmltags.DynamicSqlSource
public class DynamicSqlSource implements SqlSource {

    private final Configuration configuration;
    private final SqlNode rootSqlNode;

    @Override
    public BoundSql getBoundSql(Object parameterObject) {
        // 1. 创建动态上下文
        DynamicContext context = new DynamicContext(configuration, parameterObject);
        // 2. 遍历 SqlNode 树,拼接 SQL
        rootSqlNode.apply(context);
        // 3. 处理 #{ } → 替换为 ?
        SqlSourceBuilder sqlSourceParser = new SqlSourceBuilder(configuration);
        SqlSource sqlSource = sqlSourceParser.parse(
            context.getSql(), parameterType, context.getBindings());
        // 4. 生成 BoundSql
        BoundSql boundSql = sqlSource.getBoundSql(parameterObject);
        // 5. 绑定附加参数
        for (Map.Entry<String, Object> entry : context.getBindings().entrySet()) {
            boundSql.setAdditionalParameter(entry.getKey(), entry.getValue());
        }
        return boundSql;
    }
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

完整链路

DynamicSqlSource.getBoundSql()
  → DynamicContext 创建
  → rootSqlNode.apply(context)    ← 递归遍历 SqlNode 树
    → IfSqlNode: OGNL 求值 → 条件拼接
    → WhereSqlNode: 子节点拼接 → 去前缀 AND/OR → 加 WHERE
    → ForEachSqlNode: 集合遍历 → 生成 IN 列表
    → ... 其他 SqlNode
  → SqlSourceBuilder.parse()      ← `#{}` 替换为 ?
  → BoundSql 返回
1
2
3
4
5
6
7
8
9

# 六、常见陷阱

# 1. where标签不处理条件都为空的情况

<!-- 如果 name 和 age 都为空 -->
<select id="findUsers" resultType="User">
    SELECT * FROM user
    <where>
        <if test="name != null">
            AND name = #{name}
        </if>
        <if test="age != null">
            AND age = #{age}
        </if>
    </where>
</select>
<!-- 生成:SELECT * FROM user(无 WHERE,查全表!) -->
1
2
3
4
5
6
7
8
9
10
11
12
13

建议:业务层做校验,至少传一个条件,或在 SQL 中加上恒真条件兜底。

# 2. set标签内忘记加逗号

<!-- ❌ 错误:缺少逗号 -->
<set>
    <if test="name != null">
        name = #{name}
    </if>
    <if test="age != null">
        age = #{age}
    </if>
</set>
<!-- 如果两个都非空,生成:SET name = ? age = ? — 语法错误! -->

<!-- ✅ 正确:每个条件后加逗号,set 会自动去最后一个 -->
<set>
    <if test="name != null">
        name = #{name},
    </if>
    <if test="age != null">
        age = #{age},
    </if>
</set>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20

# 3. foreach的collection属性写错

// 接口方法
List<User> findByIds(List<Long> ids);       // collection="list"
List<User> findByIds(@Param("ids") List<Long> ids);  // collection="ids"
List<User> findByIds(Long[] ids);            // collection="array"
1
2
3
4

不使用 @Param 时,List 默认用 list,数组默认用 array。传错会报 There is no getter for property named 'xxx'

# 4. OGNL中字符串比较用单引号

<!-- ❌ 错误:双引号会被当成 OGNL 的字符串界定符 -->
<if test="type == "admin"">

<!-- ✅ 正确:单引号 -->
<if test="type == 'admin'">

<!-- ✅ 也可以用 toString() -->
<if test='type == "admin".toString()'>
1
2
3
4
5
6
7
8

# 5. foreach批量插入量过大

<!-- 批量插入 10000 条 → 生成超长 SQL → 可能超出数据库限制 -->
<insert id="batchInsert">
    INSERT INTO user (name, age) VALUES
    <foreach collection="list" item="user" separator=",">
        (#{user.name}, #{user.age})
    </foreach>
</insert>
1
2
3
4
5
6
7

建议:分批执行,每批 500-1000 条;或使用 MyBatis 的 ExecutorType.BATCH 模式。

# 七、完整示例:动态查询

<mapper namespace="com.example.mapper.UserMapper">

    <!-- SQL 片段 -->
    <sql id="userColumns">
        id, name, age, email, status, create_time
    </sql>

    <!-- 动态查询 -->
    <select id="findUsers" resultType="User">
        SELECT <include refid="userColumns"/>
        FROM user
        <where>
            <if test="status != null">
                AND status = #{status}
            </if>
            <if test="name != null and name != ''">
                <bind name="namePattern" value="'%' + name + '%'" />
                AND name LIKE #{namePattern}
            </if>
            <if test="ids != null and ids.size() > 0">
                AND id IN
                <foreach collection="ids" item="id" open="(" separator="," close=")">
                    #{id}
                </foreach>
            </if>
        </where>
        <choose>
            <when test="sortField != null and sortField != ''">
                ORDER BY ${sortField}
                <if test="sortOrder != null">
                    ${sortOrder}
                </if>
            </when>
            <otherwise>
                ORDER BY create_time DESC
            </otherwise>
        </choose>
    </select>

    <!-- 动态更新 -->
    <update id="updateUser" parameterType="User">
        UPDATE user
        <set>
            <if test="name != null">name = #{name},</if>
            <if test="age != null">age = #{age},</if>
            <if test="email != null">email = #{email},</if>
            <if test="status != null">status = #{status},</if>
        </set>
        WHERE id = #{id}
    </update>

</mapper>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

注意上面 ORDER BY 使用了 ${},因为排序字段名是标识符不能用 #{}必须在 Java 层做白名单校验

# 八、常见面试追问

# Q1:trim标签和where/set标签是什么关系?

where 和 set 都是 trim 的特例。where 等价于 prefix="WHERE" prefixOverrides="AND |OR ",set 等价于 prefix="SET" suffixOverrides=","。在源码中,WhereSqlNode 和 SetSqlNode 都继承自 TrimSqlNode,构造时传入对应的参数即可。

# Q2:动态SQL会影响性能吗?

会有一点影响,但通常可以忽略。动态 SQL 每次执行时都需要遍历 SqlNode 树、OGNL 求值、拼接 SQL 字符串,这个过程有开销。但 OGNL 表达式编译有缓存,SqlNode 树在启动时就已经构建好,运行时只是遍历和求值。相比数据库 I/O,这点开销微不足道。

需要注意的是,动态 SQL 会导致 SQL 文本变化,比如不同条件组合生成不同的 SQL,这可能影响数据库的 SQL 缓存命中率。但这也是无法避免的——不同的查询条件本来就应该生成不同的 SQL。

# Q3:SQL注入和动态SQL有关系吗?

有间接关系。动态 SQL 中的 ${} 拼接和 if/foreach 标签本身不会导致注入,但如果在动态 SQL 中使用了 ${}(如 ORDER BY 场景),且未做白名单校验,就存在注入风险。反过来,动态 SQL 的 #{} + foreach 等标签,恰恰是防止注入的正确写法——用 #{} + foreach 替代 ${} 拼接 IN 列表。

# Q4:动态SQL和静态SQL的区别是什么?

静态 SQL(RawSqlSource):SQL 在启动时就能确定完整内容,不含任何动态标签和 ${},只包含 #{} 占位符。解析一次,执行时直接拿来用。

动态 SQL(DynamicSqlSource):SQL 在执行时才能确定内容,包含 if/where/foreach 等标签。每次执行都需要遍历 SqlNode 树、OGNL 求值,动态拼接 SQL。

判断依据:XMLScriptBuilder 解析时,如果发现任何动态标签或 ${},就标记 isDynamic=true,使用 DynamicSqlSource。

# Q5:为什么OGNL表达式判断字符串相等要用单引号?

OGNL 中双引号用于界定字符串字面量,而 MyBatis XML 的属性值本身用双引号包裹。所以 test 属性内的双引号会和 XML 属性的双引号冲突,必须用单引号表示字符串值。

# 九、总结

记住三个核心要点:

1. 9个标签各有分工:
   if 条件判断、choose 多分支、where 智能WHERE、set 智能SET、
   trim 自定义裁剪、foreach 集合遍历、bind 变量绑定、
   sql/include 片段复用
   where = trim(prefix="WHERE", prefixOverrides="AND |OR ")
   set = trim(prefix="SET", suffixOverrides=",")

2. 解析原理 = SqlNode 树 + OGNL 求值
   XMLScriptBuilder.parseDynamicTags() 将 XML 标签解析为 SqlNode 树
   运行时 DynamicSqlSource 遍历 SqlNode 树
   每个节点的 apply() 方法向 DynamicContext 追加 SQL
   OGNL 表达式编译结果有缓存,不会重复编译

3. 注意常见陷阱:
   where 条件全空 → 无 WHERE 子句 → 全表查询
   set 内每个条件后必须加逗号
   foreach 的 collection 属性要和参数对应
   OGNL 字符串比较用单引号
   批量操作要控制每批数量
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21

面试回答模板

MyBatis 动态 SQL 有 9 个标签:if 做条件判断,choose/when/otherwise 做多分支选择,where 自动去除前缀 AND/OR,set 自动去除末尾逗号,trim 是自定义裁剪(where 和 set 底层都继承自 TrimSqlNode),foreach 遍历集合做 IN 查询或批量操作,bind 创建变量,sql/include 做 SQL 片段复用。

解析原理上,MyBatis 在启动时通过 XMLScriptBuilder.parseDynamicTags() 将 XML 中的动态标签递归解析为一棵 SqlNode 树,每个标签对应一个 SqlNode 实现类。运行时,DynamicSqlSource.getBoundSql() 遍历这棵树,每个 SqlNode 的 apply() 方法向 DynamicContext 追加 SQL 片段。if 标签通过 OGNL 表达式求值决定是否拼接,where/set 通过 TrimSqlNode 的裁剪逻辑处理前缀后缀,foreach 通过迭代集合拼接多次 SQL。OGNL 表达式编译有缓存,不会重复解析。

使用上需要注意几个常见陷阱:where 条件全空会导致全表查询,set 内每个条件后必须加逗号,foreach 的 collection 属性要和参数类型对应,批量操作要控制每批数量避免超限。